Skip to content

fix(cloud_firestore): deliver query snapshots before snapshotsInSync - #18921

Open
SelaseKay wants to merge 6 commits into
mainfrom
fix/issue-18920
Open

SelaseKay wants to merge 6 commits into
mainfrom
fix/issue-18920

Conversation

@SelaseKay

@SelaseKay SelaseKay commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Description

Since cloud_firestore 6.7.0, a collection or query listener and snapshotsInSync() can arrive in Dart out of order on Apple and Android. The native SDK raises the query snapshot and then the sync event. On Apple the plugin converts the query snapshot on a background queue and only then posts it to the main queue, while snapshotsInSync() posts to the main queue immediately. On Android the query listener is scheduled on a shared cached thread pool and posts to the main thread after conversion, while snapshotsInSync() is delivered on the main thread as soon as the SDK has scheduled that work. Dart can therefore see the sync event first. A document listener still emits on the main thread inside the callback the SDK queued first, so that pairing keeps the previous order.

Apple gives each Firestore instance one serial DispatchQueue. Android gives each instance one single-thread executor and passes it to both the query listener and addSnapshotsInSyncListener. The query block converts the snapshot, then posts the Dart event. The sync block posts its Dart event after that work, so the events stay in SDK order. Conversion stays off the main thread. That serialization also means a large snapshot delays other query listeners on the same instance. There is no public API or Pigeon change.

The same Android executor also stops an older query snapshot overtaking a newer one. On the shared pool, two snapshots of one query could convert at the same time and the slower one could be posted last. They now wait on one first-in, first-out thread. A per-listener queue would keep that query ordered, but the sync event would still be posted before conversion finished, so this pull request keeps the shared executor.

iOS and macOS share the Swift sources. The regression test runs on Apple and Android. On macOS it failed on the unfixed handlers with write 1: [sync, query:1:false] and passed with this change, together with the existing document snapshotsInSync() test. On an Android emulator it failed on the unfixed handlers with write 3: [sync, query:3:false] and passed with this change, again with the document test. dart analyze . --fatal-infos reported no issues in cloud_firestore and its example. The formatter reported no issues.

Not run: an iOS device or simulator, the rest of melos run analyze, the full cloud_firestore end-to-end suite, and cloud_firestore 6.6.0. This change does not touch Windows or web. Those handlers post the query event before they return to the SDK, so the sync event cannot overtake it.

Related Issues

Checklist

Before you create this PR confirm that it meets all requirements listed below by checking the relevant checkboxes ([x]).
This will ensure a smooth and quick review process. Updating the pubspec.yaml and changelogs is not required.

  • I read the Contributor Guide and followed the process outlined there for submitting PRs.
  • My PR includes unit or integration tests for all changed/updated/fixed behaviors (See Contributor Guide).
  • All existing and new tests are passing.
  • I updated/added relevant documentation (doc comments with ///).
  • The analyzer (melos run analyze) does not report any problems on my PR.
  • I read and followed the Flutter Style Guide.
  • I signed the CLA.
  • I am willing to follow-up on review comments in a timely manner.

Breaking Change

Does your PR require plugin users to manually update their apps to accommodate your change?

  • Yes, this is a breaking change.
  • No, this is not a breaking change.

Test plan

  • On macOS, with the Firestore emulator, flutter test integration_test/e2e_test.dart --name "snapshotsInSync" -d macos from packages/cloud_firestore/cloud_firestore/example. The new test expects each of 24 remote query events before the matching snapshotsInSync() event. The existing document snapshotsInSync() test is in the same run.
  • The same new test failed on the unfixed Apple handlers with write 1: [sync, query:1:false].
  • On an Android emulator, with the Firestore emulator, the same command. It failed on the unfixed handlers with write 3: [sync, query:3:false], and both snapshotsInSync tests passed after the Android change.
  • The Android app from [cloud_firestore][Android]: query snapshots() can deliver an older snapshot after a newer one since 6.7.0 #18904, five rounds of 300 awaited REST writes with CPU load. Published cloud_firestore 7.1.0 delivered older snapshots after newer ones: 55, 59, 64, 41, and 45 backward deliveries of 301. The same app built against this branch delivered 0 backward in all five rounds. None of the published rounds ended on a stale value.
  • iOS device or simulator.
  • melos run analyze for packages other than cloud_firestore and its example.

…nSync

Query conversion was queued off the listener thread while snapshotsInSync posted straight to the main queue, so Dart saw the sync event first.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a dedicated serial dispatch queue per Firestore instance (snapshotDeliveryQueue) on iOS to ensure snapshots and snapshotsInSync events are delivered in the correct order. It also adds an integration test to verify this behavior. The review feedback highlights a potential memory leak where the newly introduced snapshotDeliveryQueues are never cleaned up when Firestore instances are terminated. Additionally, the reviewer suggests using addTearDown in the integration test to guarantee the cleanup of temporary Firebase apps and stream subscriptions, which would also allow for the removal of redundant manual cleanup code.

private var transactionHandlers: [String: TransactionStreamHandler] = [:]
private let transactionLock = NSLock()
private let listenersLock = NSLock()
private var snapshotDeliveryQueues: [String: DispatchQueue] = [:]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The snapshotDeliveryQueues dictionary holds strong references to DispatchQueue instances created for each Firebase app and database URL combination. Since these queues are never removed when a Firestore instance is terminated or when the plugin is cleaned up, this will cause a memory leak of DispatchQueues and associated resources, especially in scenarios where apps are dynamically initialized and deleted (such as in integration tests).

Consider removing the queue from snapshotDeliveryQueues when the Firestore instance is terminated in terminate(app:completion:), and clearing the dictionary in cleanupEventListeners().

Comment on lines +68 to +71
final writerApp = await Firebase.initializeApp(
name: 'insync-writer-${DateTime.now().microsecondsSinceEpoch}',
options: Firebase.app().options,
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

low

To prevent resource leaks if the test fails before reaching the manual cleanup at the end of the test, use addTearDown to guarantee that the temporary Firebase app is deleted.

Suggested change
final writerApp = await Firebase.initializeApp(
name: 'insync-writer-${DateTime.now().microsecondsSinceEpoch}',
options: Firebase.app().options,
);
final writerApp = await Firebase.initializeApp(
name: 'insync-writer-${DateTime.now().microsecondsSinceEpoch}',
options: Firebase.app().options,
);
addTearDown(writerApp.delete);

Comment on lines +83 to +94
final querySub = collection.snapshots().listen((snapshot) {
String? value;
for (final document in snapshot.docs) {
if (document.id == docId) {
value = '${document.data()['n']}';
}
}
events.add('query:$value:${snapshot.metadata.hasPendingWrites}');
});
final syncSub = firestore.snapshotsInSync().listen((_) {
events.add('sync');
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

low

Similarly, use addTearDown to ensure that the stream subscriptions are canceled even if the test fails or throws an exception.

        final querySub = collection.snapshots().listen((snapshot) {
          String? value;
          for (final document in snapshot.docs) {
            if (document.id == docId) {
              value = '${document.data()["n"]}';
            }
          }
          events.add('query:$value:${snapshot.metadata.hasPendingWrites}');
        });
        addTearDown(querySub.cancel);
        final syncSub = firestore.snapshotsInSync().listen((_) {
          events.add('sync');
        });
        addTearDown(syncSub.cancel);

Comment on lines +142 to +144
await querySub.cancel();
await syncSub.cancel();
await writerApp.delete();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

low

With addTearDown registered immediately after resource creation, these manual cleanup calls at the end of the test are no longer necessary and can be safely removed.

@SelaseKay
SelaseKay marked this pull request as ready for review November 9, 2026 13:31
…sInSync

Query listeners were scheduled on the shared cached thread pool while snapshotsInSync ran on the main thread, so Dart could see the sync event first.
…idle

A single-thread executor with no timeout would keep a thread for every Firestore instance that had listened.
…worker

A core thread that times out can run a later task before an earlier one still sitting in the queue.
@SelaseKay SelaseKay changed the title fix(cloud_firestore,apple): deliver query snapshots before snapshotsInSync fix(cloud_firestore): deliver query snapshots before snapshotsInSync Nov 9, 2026
…ache

Deleting a secondary app left its Firestore cached, so the next lookup threw FirebaseApp was deleted and the rest of the Android suite failed.
The GitHub Actions scan was blocked by the pull_request_target brownout and that run cannot be retried.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants